Skip to content

fix: serialize model-backed rollout interruptions as dicts#3790

Merged
seratch merged 1 commit into
openai:mainfrom
Otis0408:fix/rollout-memory-interrupted-tool-call-items
Jul 10, 2026
Merged

fix: serialize model-backed rollout interruptions as dicts#3790
seratch merged 1 commit into
openai:mainfrom
Otis0408:fix/rollout-memory-interrupted-tool-call-items

Conversation

@Otis0408

Copy link
Copy Markdown
Contributor

Summary

Anyone using sandbox rollout memory with human-in-the-loop tool approval (Runner runs that get interrupted for approval) hits silent data corruption when the rollout is persisted.

build_rollout_payload serializes each interruption by passing ToolApprovalItem.raw_item straight into _to_dump_compatible. For an interrupted tool call, raw_item is a pydantic model (ResponseFunctionToolCall, McpCall, LocalShellCall, etc.). _to_dump_compatible has no BaseModel branch, and because pydantic v2 models are iterable (they yield (key, value) tuples), the model falls into the generic Iterable branch and is turned into a list of [key, value] pairs instead of a JSON object. Every other rollout section (input, generated_items) is pre-converted to dicts, so only the interruptions path passes a raw model through.

The fix model_dumps a model raw item before normalization, matching the existing convention in run_state._serialize_tool_input (dump first, then _to_dump_compatible). dict and other raw items are unchanged.

Before/after with the public API:

from openai.types.responses import ResponseFunctionToolCall
from agents import Agent
from agents.items import ToolApprovalItem
from agents.sandbox.memory.rollouts import build_rollout_payload, RolloutTerminalMetadata

agent = Agent(name="a")
raw = ResponseFunctionToolCall(
    id="fc_1", call_id="call_1", name="get_weather",
    arguments='{"city":"Paris"}', type="function_call",
)
payload = build_rollout_payload(
    input="hello", new_items=[], final_output=None,
    interruptions=[ToolApprovalItem(agent=agent, raw_item=raw)],
    terminal_metadata=RolloutTerminalMetadata(terminal_state="interrupted"),
)
print(payload["interruptions"][0])

# Before:
# [['arguments', '{"city":"Paris"}'], ['call_id', 'call_1'], ['name', 'get_weather'],
#  ['type', 'function_call'], ['id', 'fc_1'], ['namespace', None], ['status', None]]
# After:
# {'arguments': '{"city":"Paris"}', 'call_id': 'call_1', 'name': 'get_weather',
#  'type': 'function_call', 'id': 'fc_1'}

Test plan

Added test_build_rollout_payload_serializes_model_interruptions_as_dicts in tests/sandbox/test_memory.py, asserting the serialized interruption is a dict equal to raw_item.model_dump(exclude_unset=True). Verified it passes with the fix and fails on upstream/main (the interruption comes back as a list of [key, value] pairs). ruff format and ruff check are clean on both changed files.

Issue number

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass

`build_rollout_payload` passed a `ToolApprovalItem.raw_item` pydantic
model straight into `_to_dump_compatible`, which has no BaseModel branch.
Because pydantic v2 models are iterable, the model fell into the generic
`Iterable` branch and was serialized as a list of `[key, value]` pairs
instead of a JSON object, corrupting persisted interrupted-run rollouts.

Model raw items are now `model_dump`-ed before normalization, matching
the convention in `run_state._serialize_tool_input`.
@seratch seratch added this to the 0.18.x milestone Jul 10, 2026
@seratch seratch merged commit 322a333 into openai:main Jul 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants